%% @title ErlyDB: The Erlang Twist on Database Abstraction
%%
%% @doc For more information read the README.txt or visit
%%   http://code.google.com/p/erlydb
%%
%% @author Yariv Sadan (yarivvv@gmail.com, http://yarivsblog.com)
%%
%% @license For license information see LICENSE.txt

-module(erlydb).
-author("Yariv Sadan (yarivvv@gmail.com) (http://yarivsblog.com)").

-export(
   [start/1,
    start/2,
    code_gen/2,
    code_gen/3]).

%% useful for debugging
-define(L(Obj), io:format("LOG ~w ~p\n", [?LINE, Obj])).
-define(S(Obj), io:format("LOG ~w ~s\n", [?LINE, Obj])).

%% You can add more aggregate function names here and they will be generated
%% automatically for your modules.
aggregate_functions() ->
    [count, avg, min, max, sum, stddev].

%% @type query(). A variable representing a database query. Different
%%   ErlyDB drivers have different query types. For more information,
%%   refer to the driver's documentation.
%%
%% @type error() = {error, Error}. A standard error return value
%%   for many ErlyDB functions.
%% @type result() = ok | error(). A standard return value for
%%   many ErlyDB functions.

%% @doc Start an ErlyDB session for the driver using the options described
%%   in the Options property list. Without the Options parameters,
%%   the default options for the driver are used (this only works with
%%   some drivers, e.g. Mnesia). The options available
%%   for the driver are listed in the the driver's documentation.
%%   
%%
%% @spec start(Driver::atom(), Options::proplist()) -> result()
start(Driver) ->
    start(Driver, []).

%% Only MySQL is supported at the moment.
start(mysql, Options) ->
    erlydb_mysql:start(Options);
    
start(_Driver, _Options) ->
    {error, driver_not_supported}.
    

driver_mod(mysql) -> erlydb_mysql;
driver_mod(mnesia) -> erlydb_mnesia;
driver_mod(odbc) -> erlydb_odbc.

%% @doc Generate code for the list of modules using module and/or database
%%   metadata with the default options for the driver.
%%
%% @spec code_gen(Driver::atom(), [Module::atom()]) -> ok | {error, Err}
code_gen(Driver, Modules) ->
    code_gen(Driver, Modules, undefined).

%% @doc Generate code for the list of modules using module and/or database
%%   metadata with the specific options for the driver. For example,
%%   the MySQL driver accepts the {pool_id, Id::atom()} and
%%   {allow_unsafe_statements, Val::bool()} options.
%%   For more details refer to the driver's documentation.
%%
%% @spec code_gen(Driver::atom(), [Module::atom()]) -> ok | {error, Err}
code_gen(Driver, Modules, Options) ->
    DriverMod = driver_mod(Driver),
    case DriverMod:get_metadata(Options) of
	{error, _Err}  = Res ->
	    Res;
	{ok, Metadata} ->
	    catch lists:foreach(
		    fun(Module) ->
			    case process_module(DriverMod, Module, Metadata,
						Options) of
				ok ->
				    ok;
				Err ->
				    throw(Err)
			    end
		    end, Modules)
    end.

%% Generate the database abstraction layer for the given module.
process_module(DriverMod, Module, Metadata, Options) ->
    case smerl:for_module(Module) of
	{ok, C1} ->
	    case gb_trees:lookup(get_table(Module), Metadata) of
		{value, Fields} ->
		    MetaMod = make_module(DriverMod, C1, Fields, Options),
		    ok = smerl:compile(MetaMod);
		none ->
		    {error, {no_such_table, Module}}
	    end;
	Err ->
	    Err
    end.

get_table(Module) ->
    case catch Module:table() of
	{'EXIT', _} -> Module;
	Res -> Res
    end.

%% Make the abstract forms for the module.
make_module(DriverMod, MetaMod, Fields, Options) ->
    %% extend the base module, erlydb_base
    M2 = smerl:extend(erlydb_base, MetaMod),

    %% inject the fields list into the db_fields/1 function
    {ok, M3} = smerl:curry_replace(M2, db_fields, 1, [Fields]),

    Module = smerl:get_module(MetaMod),

    {ok, M4} = smerl:curry_replace(M3, used_fields, 1, [Module]),
    ok = smerl:compile(M4),
    
    ShownFields = Module:used_fields(),

    {M6, _Count} = lists:foldl(
		     fun(Field, {M5, Count}) ->
			     Idx = Count,
			     {make_field_forms(M5, Field, Idx),
			      Count+1}
		     end, {M4, 3}, ShownFields),


    %% created the constructor
    {ok, M7} = smerl:add_func(M6,
			      make_new_func(Module, ShownFields)),
    
    %% inject the driver configuration into the driver/1 function
    {ok, M8} = smerl:curry_replace(M7, driver, 1, [{DriverMod, Options}]),

    %% make the relations function forms
    M9 = make_rel_funcs(M8),

    %% make the aggregate function forms
    M10 = make_aggregate_forms(M9, aggregate, 5, [Module],
			     undefined),

    %% add extra configurations to the different find functions
    M12 = lists:foldl(
	   fun({FindFunc, Arity}, M11) ->
		   add_find_configs(M11, FindFunc, Arity)
	   end, M10, [{find, 3}, {find_first, 3}, {find_max, 4},
		     {find_range,5}]),

    %% embed the generated module's name in
    %% place of all corresponding parameters in the base forms
    M13 = smerl:embed_all(M12, [{'Module', smerl:get_module(MetaMod)}]),

    M13.

%% Create the abstract form for the given Module's 'new' function.
%%
%% For related records, the 'new' function accepts as a parameter
%% either a tuple representing the related record, or the record's
%% id directly.
%%
%% Example:
%% {ok, Erlang} = language:find_id(1),
%% project:new("Yaws", Lang) == project:new("Yaws", language:id(Lang))
make_new_func(Module, [_IdField | Fields]) ->
    L = 1,
    Params =
	lists:map(
	  fun(Field) ->
		  {_Res, Field1} = strip_id_chars(Field),
    		  {var,L,Field1}
	  end, Fields),
    
    Values  =
	lists:map(
	  fun(Field) ->
		  case strip_id_chars(Field) of
		      {true, Field1} ->
			  make_new_func_if_expr(Field1);
		      {false, _Field1} ->
			  {var, L, Field}
		  end
	  end, Fields),
    
    Func =
	{function,L,new,length(Fields),
	 [{clause,L,Params,[],
	   [{tuple,L,
	     [{atom,L,Module},{atom,L,true},{atom,L,undefined}|Values]}
	   ]}
	 ]},
    Func.			

%% Return the following expression:
%%
%% if is_tuple(Param) -> 'Param':id(Param); true -> Param end
%%
%% This allows you to pass into the constructor either a related record
%% or the related record's id directly. If you pass in a related,
%% record, its id is automatically substituted as the parameter's
%% value.
make_new_func_if_expr(Param) ->
    L = 1,
    {'if',L,
     [{clause,L,
       [],
       [[{call,L,{atom,L,is_tuple},[{var,L,Param}]}]],
       [{call,L,
	 {remote,L,{atom,L,Param},{atom,L,id}},
	 [{var,L,Param}]}]},
      {clause,L,[],[[{atom,L,true}]],[{var,L,Param}]}]}.

%% If Field is an atom such as 'person_id', return the atom 'person'.
%% Otherwise, return the original atom.
strip_id_chars(Field) ->
    FieldName = atom_to_list(Field),
    FieldLen = length(FieldName),
    LastThreeChars =
	string:substr(FieldName,
		      FieldLen - 2, 3),
    if
	LastThreeChars == "_id" ->
	    {true, list_to_atom(string:substr(FieldName, 1, FieldLen - 3))};
	true ->
	    {false, Field}
    end.
	    
%% Add getters and setters
make_field_forms(MetaMod, Field, Idx) ->
    {ok, C1} = smerl:curry_add(MetaMod, get, 2, Idx, Field),
    {ok, C2} = smerl:curry_add(C1, set, 3, Idx, Field),
    C2.

make_aggregate_forms(MetaMod, BaseFuncName, Arity, CurryParams, PostFix) ->
    lists:foldl(
      fun(Func, M1) ->
	      NewName = append([Func, PostFix]),
	      NewCurryParams = CurryParams ++ [Func],
	      {ok, M2} = smerl:curry_add(M1, BaseFuncName, Arity,
					 NewCurryParams,
					 NewName),
	      add_find_configs(M2, NewName, Arity - length(NewCurryParams))
      end, MetaMod, aggregate_functions()).

%% Generate the forms for functions that enable working with related
%% records.
make_rel_funcs(MetaMod) ->
    Module = smerl:get_module(MetaMod),
    lists:foldl(
      fun({RelType, Modules}, MetaMod1) ->
	      make_rel_forms(RelType,
			     Modules, MetaMod1)
      end, MetaMod, Module:relations()).

make_rel_forms(RelType, Modules, MetaMod) ->
    Fun =
	case RelType of
	    many_to_one ->
		fun make_many_to_one_forms/2;
	    one_to_many ->
		fun make_one_to_many_forms/2;
	    many_to_many ->
		fun make_many_to_many_forms/2
	end,

    M1 = lists:foldl(Fun, MetaMod, Modules),
    M1.    

make_many_to_one_forms(OtherModule, MetaMod) ->
    {ok, M1} = smerl:curry_add(MetaMod, find_related_one_to_many, 2,
			       [OtherModule], OtherModule),
    {ok, M2} = smerl:curry_add(M1, set_related_one_to_many, 2,
		   [], OtherModule),
    M2.

make_one_to_many_forms(OtherModule, MetaMod) ->
    make_some_to_many_forms(
      MetaMod, OtherModule, [],
      find_related_many_to_one, 4,
      aggregate_related_many_to_one, 6).

add_find_configs(MetaMod, BaseFuncName, Arity) ->
    NoWhere = {'Where', undefined},
    NoExtras = {'Extras', undefined},

    Configs = 
	[{BaseFuncName, [NoWhere, NoExtras]},
	 {BaseFuncName, [NoExtras]},
	 {append([BaseFuncName, "_with"]), [NoWhere]}],

    M4 = lists:foldl(
	   fun({NewName, Replacements}, M2) ->
		   {ok, M3} =
		       smerl:embed_params(M2, BaseFuncName, Arity,
					  Replacements, NewName),
		   M3
	   end, MetaMod, Configs),
    M4.
    
make_some_to_many_forms(MetaMod, OtherModule, ExtraCurryParams,
		       BaseFindFuncName, BaseFindFuncArity,
		       AggregateFuncName, AggregateFuncArity) ->
    FindFuncName = pluralize(OtherModule),
    {ok, M1} = smerl:curry_add(MetaMod, BaseFindFuncName, BaseFindFuncArity,
		     [OtherModule | ExtraCurryParams], FindFuncName),

    M2 = add_find_configs(M1, FindFuncName, BaseFindFuncArity -
			  (1+ length(ExtraCurryParams))),

    AggPostFix = "_of_" ++ atom_to_list(pluralize(OtherModule)),
    M3 = make_aggregate_forms(M2, AggregateFuncName, AggregateFuncArity,
			      [OtherModule | ExtraCurryParams], AggPostFix),
    
    FindFuncs = [
 		 {find_related_many_first,4},
 		 {find_related_many_max,5},
		 {find_related_many_range,6}
		],

    M6 = lists:foldl(
	   fun({FuncName, Arity}, M4) ->
		   PostFix = lists:nthtail(length("find_related_many"),
					   atom_to_list(FuncName)),
		   NewName = append([FindFuncName, PostFix]),
		   {ok, M5} = smerl:curry_add(M4, FuncName, Arity,
					      [FindFuncName], NewName),
		   add_find_configs(M5, NewName, Arity-1)
	   end, M3, FindFuncs),

    CountFuncName = append(["count", AggPostFix]),
    {ok, M7} =
	smerl:embed_params(M6, CountFuncName, 2, [{'Field', '*'}]),
    M7.

make_many_to_many_forms(OtherModule, MetaMod) ->
    ModuleName = smerl:get_module(MetaMod),
    
    %% The name of the join table is currently assumed
    %% to be the alphabetical ordering of the two tables,
    %% separated by an underscore.
    %% Good example: person_project
    %% Bad example: project_person
    [Module1, Module2] = 
	lists:sort([ModuleName, OtherModule]),
    JoinTableName = append([Module1, "_", Module2]),

    CurryFuncs =
	[{add_related_many_to_many,
	  append(["add_", OtherModule])},
	 {remove_related_many_to_many,
	  append(["remove_", OtherModule])}],
    
    M3 = lists:foldl(
	   fun({FuncName,NewName}, M1) ->
		   {ok, M2} = smerl:curry_add(
				M1, FuncName, 3, [JoinTableName],
				NewName),
		   M2
	   end, MetaMod, CurryFuncs), 

	   
    M4 = make_some_to_many_forms(
	   M3, OtherModule, [JoinTableName],
	   find_related_many_to_many, 5,
	   aggregate_related_many_to_many, 7),
				 
    M4.


%% TODO There are probably a bunch of additional cases, but this is good
%% enough for now :)
pluralize(Module) ->
    pluralize(Module, undefined).

pluralize(Module, Postfix) ->
    Str = atom_to_list(Module),
    [LastChar, CharBeforeLast|Rest] = Rev = lists:reverse(Str),
    Suffix = [CharBeforeLast,LastChar],
    Irregulars =
	[{man, men},{foot,feet},{child,children},{person,people},
	 {tooth,teeth},{mouse,mice},{sheep,sheep},{deer,deer},{fish,fish}],
    PluralForm =
	case lists:keysearch(Module,1,Irregulars) of
	    {value, {_, Plural}} ->
		atom_to_list(Plural);
	    _ ->
		if Suffix == "fe" ->
			lists:reverse([$s,$e,$v | Rest]);
		   LastChar == $f ->
			lists:reverse([$s,$e,$v,CharBeforeLast | Rest]);
		   
		   %% These rules only work in some special cases, so we'll
		   %% comment them out for now and possibly deal with them
		   %% later
		   %% Suffix == "is" ->
		   %%	lists:reverse([$s,$e|Rest]);
		   %% Suffix == "on" ->
		   %%   lists:reverse([$a | Rest]);
		   %% Suffix == "us" ->
		   %%   lists:reverse([$i | Rest]);

		   true ->
			Cond1 = LastChar == $y andalso
			    lists:member(CharBeforeLast,
					 "bcdfghjklmnpqrtvwxyz"),
			if
			    Cond1 ->
				lists:reverse([$s,$e,$i,CharBeforeLast|Rest]);
			    true ->
				Cond2 = case Rev of
					    [$s|_] -> true;
					    [$h,$c|_] -> true;
					    [$h,$s|_] -> true;
					    [$x|_] -> true;
					    [$o|_] -> true;
					    _ -> false
					end,
				if Cond2 ->
					Str ++ "es";
				   true ->
					Str ++ "s"
				end
			end
		end
	end,
    Result =
	case Postfix of
	    undefined ->
		PluralForm;
	    _ ->
		PluralForm ++ Postfix
	end,
    list_to_atom(Result).

%% append a list of strings and atoms and return the result as an atom
append(Terms) ->
    list_to_atom(
      lists:flatten(
	lists:map(
	  fun(undefined) -> [];
	     (Atom) when is_atom(Atom) ->
		  atom_to_list(Atom);
	     (List) -> List
	  end, Terms))).
